أتقن إدارة المتغيرات على مستوى الطلب في Node.js باستخدام AsyncLocalStorage. تخلص من تمرير الخصائص (prop drilling) وابنِ تطبيقات أنظف وأكثر قابلية للمراقبة لجمهور عالمي.
فتح آفاق سياق JavaScript غير المتزامن: نظرة معمقة على إدارة المتغيرات المرتبطة بالطلب
في عالم تطوير الخوادم الحديث، تعد إدارة الحالة تحديًا أساسيًا. بالنسبة للمطورين الذين يعملون مع Node.js، يتضخم هذا التحدي بسبب طبيعته غير المتزامنة، أحادية الخيط، وغير الحاجبة. بينما يُعد هذا النموذج قويًا بشكل لا يصدق لبناء تطبيقات عالية الأداء ومرتبطة بعمليات الإدخال/الإخراج، فإنه يطرح مشكلة فريدة: كيف تحافظ على سياق طلب معين أثناء تدفقه عبر عمليات غير متزامنة مختلفة، من البرمجيات الوسيطة (middleware) إلى استعلامات قواعد البيانات وصولًا إلى استدعاءات واجهات برمجة التطبيقات (API) التابعة لجهات خارجية؟ كيف تضمن عدم تسرب بيانات من طلب مستخدم إلى طلب مستخدم آخر؟
لسنوات، تصارع مجتمع JavaScript مع هذه المشكلة، وغالبًا ما لجأ إلى أنماط مرهقة مثل "تمرير الخصائص" (prop drilling) — وهي تمرير بيانات خاصة بالطلب مثل معرف المستخدم أو معرف التتبع عبر كل دالة في سلسلة الاستدعاءات. هذا النهج يรกم الكود، ويخلق اقترانًا وثيقًا بين الوحدات، ويجعل الصيانة كابوسًا متكررًا.
وهنا يأتي دور السياق غير المتزامن (Async Context)، وهو مفهوم يوفر حلاً قويًا لهذه المشكلة طويلة الأمد. مع إدخال واجهة برمجة التطبيقات المستقرة AsyncLocalStorage في Node.js، أصبح لدى المطورين الآن آلية مدمجة وقوية لإدارة المتغيرات المرتبطة بالطلب بأناقة وكفاءة. سيأخذك هذا الدليل في رحلة شاملة عبر عالم سياق JavaScript غير المتزامن، موضحًا المشكلة، ومقدمًا الحل، وموفرًا أمثلة عملية من العالم الحقيقي لمساعدتك في بناء تطبيقات أكثر قابلية للتوسع والصيانة والمراقبة لقاعدة مستخدمين عالمية.
التحدي الأساسي: الحالة في عالم متزامن وغير متزامن
لتقدير الحل بشكل كامل، يجب أن نفهم أولاً عمق المشكلة. يتعامل خادم Node.js مع آلاف الطلبات المتزامنة. عندما يصل الطلب "أ"، قد يبدأ Node.js في معالجته، ثم يتوقف مؤقتًا لانتظار اكتمال استعلام قاعدة البيانات. أثناء انتظاره، يلتقط الطلب "ب" ويبدأ العمل عليه. بمجرد عودة نتيجة قاعدة البيانات للطلب "أ"، يستأنف Node.js تنفيذه. هذا التبديل المستمر للسياق هو السحر وراء أدائه، ولكنه يعيث فسادًا في تقنيات إدارة الحالة التقليدية.
لماذا تفشل المتغيرات العامة (Global Variables)
قد تكون الغريزة الأولى للمطور المبتدئ هي استخدام متغير عام. على سبيل المثال:
let currentUser; // متغير عام
// برنامج وسيط لتعيين المستخدم
app.use((req, res, next) => {
currentUser = await getUserFromDb(req.headers.authorization);
next();
});
// دالة خدمة في عمق التطبيق
function logActivity() {
console.log(`نشاط للمستخدم: ${currentUser.id}`);
}
هذا عيب تصميمي كارثي في بيئة متزامنة. إذا قام الطلب "أ" بتعيين currentUser ثم انتظر عملية غير متزامنة، فقد يأتي الطلب "ب" ويعيد الكتابة فوق currentUser قبل انتهاء الطلب "أ". عندما يستأنف الطلب "أ" عمله، سيستخدم بشكل غير صحيح البيانات من الطلب "ب". هذا يخلق أخطاء غير متوقعة، وتلفًا للبيانات، وثغرات أمنية. المتغيرات العامة ليست آمنة للطلبات (request-safe).
معاناة تمرير الخصائص (Prop Drilling)
الحل الأكثر شيوعًا وأمانًا هو "تمرير الخصائص" أو "تمرير المعلمات". يتضمن هذا تمرير السياق بشكل صريح كوسيط لكل دالة تحتاجه.
لنتخيل أننا بحاجة إلى traceId فريد للتسجيل وكائن user للتحقق من الصلاحية في جميع أنحاء تطبيقنا.
مثال على تمرير الخصائص:
// 1. نقطة الدخول: البرنامج الوسيط
app.use((req, res, next) => {
const traceId = generateTraceId();
const user = { id: 'user-123', locale: 'en-GB' };
const requestContext = { traceId, user };
processOrder(requestContext, req.body.orderId);
});
// 2. طبقة منطق الأعمال
function processOrder(context, orderId) {
log('معالجة الطلب', context);
const orderDetails = getOrderDetails(context, orderId);
// ... المزيد من المنطق
}
// 3. طبقة الوصول إلى البيانات
function getOrderDetails(context, orderId) {
log(`جلب الطلب ${orderId}`, context);
return db.query('SELECT * FROM orders WHERE id = ?', orderId);
}
// 4. طبقة الأدوات المساعدة
function log(message, context) {
console.log(`[${context.traceId}] [المستخدم: ${context.user.id}] - ${message}`);
}
على الرغم من أن هذا يعمل وهو آمن من مشاكل التزامن، إلا أن له عيوبًا كبيرة:
- فوضى الكود: يتم تمرير كائن
contextفي كل مكان، حتى عبر الدوال التي لا تستخدمه مباشرة ولكنها تحتاج إلى تمريره إلى الدوال التي تستدعيها. - الاقتران الوثيق: أصبح كل توقيع دالة الآن مقترنًا بشكل كائن
context. إذا احتجت إلى إضافة جزء جديد من البيانات إلى السياق (على سبيل المثال، علامة اختبار A/B)، فقد تضطر إلى تعديل العشرات من تواقيع الدوال عبر قاعدة الكود الخاصة بك. - انخفاض قابلية القراءة: يمكن أن يحجب الغرض الأساسي للدالة بسبب الكود المتكرر لتمرير السياق.
- عبء الصيانة: تصبح إعادة الهيكلة (refactoring) عملية مملة وعرضة للخطأ.
كنا بحاجة إلى طريقة أفضل. طريقة للحصول على حاوية "سحرية" تحتفظ بالبيانات الخاصة بالطلب، ويمكن الوصول إليها من أي مكان داخل سلسلة الاستدعاءات غير المتزامنة لذلك الطلب، دون تمرير صريح.
إليكم `AsyncLocalStorage`: الحل الحديث
تُعد فئة AsyncLocalStorage، وهي ميزة مستقرة منذ إصدار Node.js v13.10.0، الإجابة الرسمية لهذه المشكلة. فهي تسمح للمطورين بإنشاء سياق تخزين معزول يستمر عبر سلسلة كاملة من العمليات غير المتزامنة التي تبدأ من نقطة دخول معينة.
يمكنك التفكير في الأمر على أنه شكل من أشكال "التخزين المحلي للخيط" (thread-local storage) لعالم JavaScript غير المتزامن والقائم على الأحداث. عندما تبدأ عملية داخل سياق AsyncLocalStorage، فإن أي دالة يتم استدعاؤها من تلك النقطة فصاعدًا - سواء كانت متزامنة، أو قائمة على ردود الاتصال (callback)، أو قائمة على الوعود (promise) - يمكنها الوصول إلى البيانات المخزنة في ذلك السياق.
مفاهيم الواجهة البرمجية الأساسية (API)
الواجهة البرمجية بسيطة وقوية بشكل ملحوظ. وتتمحور حول ثلاث طرق رئيسية:
new AsyncLocalStorage(): تنشئ نسخة جديدة من المخزن. عادةً ما تنشئ نسخة واحدة لكل نوع من السياق (على سبيل المثال، واحدة لجميع طلبات HTTP) وتشاركها عبر تطبيقك.als.run(store, callback): هذه هي الطريقة الأساسية. فهي تنفذ دالة (callback) وتنشئ سياقًا غير متزامن جديد. الوسيط الأول،store، هو البيانات التي تريد إتاحتها داخل هذا السياق. أي كود يتم تنفيذه داخلcallback، بما في ذلك العمليات غير المتزامنة، سيكون لديه حق الوصول إلى هذاstore.als.getStore(): تُستخدم هذه الطريقة لاسترداد البيانات (store) من السياق الحالي. إذا تم استدعاؤها خارج سياق تم إنشاؤه بواسطةrun()، فستعيدundefined.
التنفيذ العملي: دليل خطوة بخطوة
دعنا نعيد هيكلة مثال تمرير الخصائص السابق باستخدام AsyncLocalStorage. سنستخدم خادم Express.js قياسيًا، لكن المبدأ هو نفسه لأي إطار عمل Node.js أو حتى وحدة http الأصلية.
الخطوة 1: إنشاء نسخة مركزية من `AsyncLocalStorage`
من أفضل الممارسات إنشاء نسخة واحدة مشتركة من مخزنك وتصديرها بحيث يمكن استخدامها في جميع أنحاء تطبيقك. لنقم بإنشاء ملف باسم asyncContext.js.
// asyncContext.js
import { AsyncLocalStorage } from 'async_hooks';
export const requestContextStore = new AsyncLocalStorage();
الخطوة 2: تأسيس السياق باستخدام برنامج وسيط (Middleware)
المكان المثالي لبدء السياق هو في بداية دورة حياة الطلب. البرنامج الوسيط (middleware) مثالي لهذا الغرض. سنقوم بإنشاء بياناتنا الخاصة بالطلب ثم نغلف بقية منطق معالجة الطلب داخل als.run().
// server.js
import express from 'express';
import { requestContextStore } from './asyncContext.js';
import { v4 as uuidv4 } from 'uuid'; // لتوليد traceId فريد
const app = express();
// البرنامج الوسيط السحري
app.use((req, res, next) => {
const traceId = req.headers['x-request-id'] || uuidv4();
const user = { id: 'user-123', locale: 'en-GB' }; // في تطبيق حقيقي، يأتي هذا من برنامج وسيط للمصادقة
const store = { traceId, user };
// تأسيس السياق لهذا الطلب
requestContextStore.run(store, () => {
next();
});
});
// ... مساراتك والبرامج الوسيطة الأخرى هنا
في هذا البرنامج الوسيط، لكل طلب وارد، نقوم بإنشاء كائن store يحتوي على traceId و user. ثم نستدعي requestContextStore.run(store, ...). يضمن استدعاء next() بالداخل أن جميع البرامج الوسيطة ومعالجات المسارات اللاحقة لهذا الطلب المحدد سيتم تنفيذها ضمن هذا السياق الذي تم إنشاؤه حديثًا.
الخطوة 3: الوصول إلى السياق في أي مكان، بدون تمرير الخصائص
الآن، يمكن تبسيط وحداتنا الأخرى بشكل جذري. لم تعد بحاجة إلى معلمة context. يمكنها ببساطة استيراد requestContextStore الخاص بنا واستدعاء getStore().
أداة تسجيل الأحداث بعد إعادة الهيكلة:
// logger.js
import { requestContextStore } from './asyncContext.js';
export function log(message) {
const context = requestContextStore.getStore();
if (context) {
const { traceId, user } = context;
console.log(`[${traceId}] [المستخدم: ${user.id}] - ${message}`);
} else {
// بديل للسجلات خارج سياق الطلب
console.log(`[NO_CONTEXT] - ${message}`);
}
}
طبقات الأعمال والبيانات بعد إعادة الهيكلة:
// orderService.js
import { log } from './logger.js';
import * as db from './database.js';
export function processOrder(orderId) {
log('معالجة الطلب'); // لا حاجة للسياق!
const orderDetails = getOrderDetails(orderId);
// ... المزيد من المنطق
}
function getOrderDetails(orderId) {
log(`جلب الطلب ${orderId}`); // سيلتقط المسجل السياق تلقائيًا
return db.query('SELECT * FROM orders WHERE id = ?', orderId);
}
الفرق شاسع. الكود أصبح أنظف بشكل كبير، وأكثر قابلية للقراءة، ومفصول تمامًا عن بنية السياق. أداة تسجيل الأحداث ومنطق الأعمال وطبقات الوصول إلى البيانات أصبحت الآن نقية ومركزة على مهامها المحددة. إذا احتجنا في أي وقت إلى إضافة خاصية جديدة إلى سياق الطلب الخاص بنا، فنحن بحاجة فقط إلى تغيير البرنامج الوسيط حيث يتم إنشاؤه. لا يلزم المساس بأي توقيع دالة آخر.
حالات الاستخدام المتقدمة ومنظور عالمي
السياق المرتبط بالطلب ليس فقط لتسجيل الأحداث. إنه يفتح مجموعة متنوعة من الأنماط القوية والأساسية لبناء تطبيقات عالمية متطورة.
1. التتبع الموزع وقابلية المراقبة
في بنية الخدمات المصغرة (microservices)، يمكن لإجراء مستخدم واحد أن يؤدي إلى سلسلة من الطلبات عبر خدمات متعددة. لتصحيح المشكلات، تحتاج إلى أن تكون قادرًا على تتبع هذه الرحلة بأكملها. يُعد AsyncLocalStorage حجر الزاوية في التتبع الحديث. يمكن تعيين traceId فريد لطلب وارد إلى بوابة واجهة برمجة التطبيقات (API gateway). يتم بعد ذلك تخزين هذا المعرف في السياق غير المتزامن ويتم تضمينه تلقائيًا في أي استدعاءات API صادرة (على سبيل المثال، كترويسة HTTP) إلى الخدمات النهائية. تقوم كل خدمة بنفس الشيء، وتنشر السياق. يمكن لمنصات تسجيل الأحداث المركزية بعد ذلك استيعاب هذه السجلات وإعادة بناء التدفق الكامل والشامل للطلب عبر نظامك بأكمله.
2. التدويل (i18n) والتعريب (l10n)
بالنسبة لتطبيق عالمي، يعد عرض التواريخ والأوقات والأرقام والعملات بالتنسيق المحلي للمستخدم أمرًا بالغ الأهمية. يمكنك تخزين الإعدادات المحلية للمستخدم (على سبيل المثال، 'fr-FR', 'ja-JP', 'en-US') من ترويسات طلبه أو ملفه الشخصي في السياق غير المتزامن.
// أداة لتنسيق العملة
import { requestContextStore } from './asyncContext.js';
function formatCurrency(amount, currencyCode) {
const context = requestContextStore.getStore();
const locale = context?.user?.locale || 'en-US'; // قيمة افتراضية بديلة
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).format(amount);
}
// الاستخدام في عمق التطبيق
const priceString = formatCurrency(199.99, 'EUR'); // يستخدم الإعدادات المحلية للمستخدم تلقائيًا
هذا يضمن تجربة مستخدم متسقة دون الحاجة إلى تمرير متغير locale في كل مكان.
3. إدارة معاملات قاعدة البيانات
عندما يحتاج طلب واحد إلى تنفيذ عمليات كتابة متعددة في قاعدة البيانات يجب أن تنجح أو تفشل معًا، فإنك تحتاج إلى معاملة (transaction). يمكنك بدء معاملة في بداية معالج الطلب، وتخزين عميل المعاملة في السياق غير المتزامن، ثم جعل جميع استدعاءات قاعدة البيانات اللاحقة داخل هذا الطلب تستخدم نفس عميل المعاملة تلقائيًا. في نهاية المعالج، يمكنك تثبيت (commit) أو التراجع عن (rollback) المعاملة بناءً على النتيجة.
4. تبديل الميزات واختبار A/B
يمكنك تحديد علامات الميزات (feature flags) أو مجموعات اختبار A/B التي ينتمي إليها المستخدم في بداية الطلب وتخزين هذه المعلومات في السياق. يمكن لأجزاء مختلفة من تطبيقك، من طبقة API إلى طبقة العرض، الرجوع إلى السياق لتحديد أي إصدار من الميزة سيتم تنفيذه أو أي واجهة مستخدم سيتم عرضها، مما يخلق تجربة مخصصة دون تمرير معلمات معقدة.
اعتبارات الأداء وأفضل الممارسات
سؤال شائع هو: ما هي التكلفة الإضافية على الأداء؟ لقد استثمر فريق Node.js الأساسي جهدًا كبيرًا في جعل AsyncLocalStorage عالي الكفاءة. فهو مبني فوق واجهة برمجة التطبيقات async_hooks على مستوى C++ ومتكامل بعمق مع محرك JavaScript V8. بالنسبة للغالبية العظمى من تطبيقات الويب، فإن التأثير على الأداء لا يكاد يذكر ويتجاوزه بكثير المكاسب الهائلة في جودة الكود وقابلية الصيانة.
لاستخدامه بفعالية، اتبع أفضل الممارسات التالية:
- استخدم نسخة فريدة (Singleton): كما هو موضح في مثالنا، قم بإنشاء نسخة واحدة مصدرة من
AsyncLocalStorageلسياق طلبك لضمان الاتساق. - أسس السياق عند نقطة الدخول: استخدم دائمًا برنامجًا وسيطًا على المستوى الأعلى أو بداية معالج الطلب لاستدعاء
als.run(). هذا يخلق حدودًا واضحة ويمكن التنبؤ بها لسياقك. - تعامل مع المخزن على أنه غير قابل للتغيير: على الرغم من أن كائن المخزن نفسه قابل للتغيير، إلا أنه من الممارسات الجيدة التعامل معه على أنه غير قابل للتغيير. إذا كنت بحاجة إلى إضافة بيانات في منتصف الطلب، فغالبًا ما يكون من الأنظف إنشاء سياق متداخل باستدعاء
run()آخر، على الرغم من أن هذا نمط أكثر تقدمًا. - تعامل مع الحالات التي لا يوجد بها سياق: كما هو موضح في مسجل الأحداث الخاص بنا، يجب أن تتحقق أدواتك المساعدة دائمًا مما إذا كانت
getStore()تعيدundefined. هذا يسمح لها بالعمل بسلاسة عند تشغيلها خارج سياق الطلب، كما هو الحال في البرامج النصية الخلفية أو أثناء بدء تشغيل التطبيق. - معالجة الأخطاء تعمل ببساطة: ينتشر السياق غير المتزامن بشكل صحيح عبر سلاسل
Promise، وكتل.then()/.catch()/.finally()، وasync/awaitمعtry/catch. لا تحتاج إلى القيام بأي شيء خاص؛ إذا تم طرح خطأ، يظل السياق متاحًا في منطق معالجة الأخطاء الخاص بك.
الخلاصة: عصر جديد لتطبيقات Node.js
إن AsyncLocalStorage أكثر من مجرد أداة مساعدة مريحة؛ إنه يمثل نقلة نوعية في إدارة الحالة في JavaScript من جانب الخادم. فهو يوفر حلاً نظيفًا وقويًا وعالي الأداء للمشكلة طويلة الأمد المتمثلة في إدارة السياق المرتبط بالطلب في بيئة متزامنة للغاية.
من خلال تبني واجهة برمجة التطبيقات هذه، يمكنك:
- التخلص من تمرير الخصائص (Prop Drilling): كتابة دوال أنظف وأكثر تركيزًا.
- فصل الوحدات (Decoupling): تقليل الاعتماديات وجعل الكود أسهل في إعادة الهيكلة والاختبار.
- تحسين قابلية المراقبة: تنفيذ تتبع موزع قوي وتسجيل أحداث سياقي بسهولة.
- بناء ميزات متطورة: تبسيط الأنماط المعقدة مثل إدارة المعاملات والتدويل.
بالنسبة للمطورين الذين يبنون تطبيقات حديثة وقابلة للتطوير ومدركة عالميًا على Node.js، لم يعد إتقان السياق غير المتزامن اختياريًا - بل أصبح مهارة أساسية. من خلال تجاوز الأنماط القديمة واعتماد AsyncLocalStorage، يمكنك كتابة كود ليس فقط أكثر كفاءة، ولكنه أيضًا أكثر أناقة وقابلية للصيانة بشكل كبير.